Skip to content

fix(sdk): restore cache token fallbacks in _cache_buckets for Kimi-K2 and Anthropic streaming - #5019

Draft
all-hands-bot wants to merge 1 commit into
mainfrom
fix/telemetry-cache-token-regression
Draft

all-hands-bot wants to merge 1 commit into
mainfrom
fix/telemetry-cache-token-regression

Conversation

@all-hands-bot

@all-hands-bot all-hands-bot commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator

HUMAN:


AGENT:

Why

Source PR reviewed: #4968 (fix(sdk): generate titles with Responses and subscription streaming), authored by @neubig, approved by @enyst.

Applicable risk lenses: #1 (Boundary contracts and alternate valid inputs), #10 (Negative-space and silent-success cases), #8 (Credentials, redaction, and sensitive persistence boundaries — cost telemetry accuracy).

Deterministic reproduction and impact:

PR #4968 rewrote Telemetry._cache_buckets() to only check prompt_tokens_details.cached_tokens / cache_creation_tokens. It dropped all fallback paths that previously handled provider-specific usage shapes:

  1. Kimi-K2 / DeepSeek — these providers populate usage.cached_tokens directly on the Usage object (via model_extra), not inside prompt_tokens_details. After fix(sdk): generate titles with Responses and subscription streaming #4968, _cache_buckets returns (0, 0) because prompt_tokens_details is None.
  2. Anthropic streaming — litellm populates private attributes _cache_read_input_tokens and _cache_creation_input_tokens during streaming, where prompt_tokens_details may be absent. After fix(sdk): generate titles with Responses and subscription streaming #4968, these are silently ignored.

Impact: Cache-read and cache-write token accounting is silently zeroed for Kimi-K2, DeepSeek, and Anthropic streaming responses. This produces incorrect cost metrics (cache-read tokens are typically discounted 10x) and misleading telemetry spans. The bug is silent — no exception is raised, the feature just returns wrong numbers.

Root cause: The refactored _cache_buckets replaced the multi-source getattr fallback chain with a single-path check on prompt_tokens_details, without accounting for providers that populate cache token counts through alternate attributes.

Proof that the regression test fails on unmodified main:

# On unmodified main (76e9e25):
$ git stash  # remove fix
$ uv run pytest tests/sdk/llm/test_llm_telemetry.py -k "kimi or anthropic" -v

tests/sdk/llm/test_llm_telemetry.py::TestTelemetryTokenUsage::test_record_usage_cache_read_kimi_cached_tokens FAILED
tests/sdk/llm/test_llm_telemetry.py::TestTelemetryTokenUsage::test_record_usage_cache_read_anthropic_private_attr FAILED
tests/sdk/llm/test_llm_telemetry.py::TestTelemetryTokenUsage::test_record_usage_cache_write_anthropic_private_attr PASSED (only passed because cache_write fallback via model_fields_set was retained)

# Standalone probe on clean main also confirms:
# test_kimi_k2_cached_tokens: FAILED (Expected 80 cached tokens, got 0)
# test_anthropic_private_attrs: FAILED (Expected 800 cache read, got 0)

Why the original tests did not catch the case: PR #4968's test suite only tested cache token extraction via prompt_tokens_details (the standard OpenAI shape). No test exercised the Kimi-K2 cached_tokens path or the Anthropic private-attr path, so the regression went undetected.

Summary

  • Restore the multi-source fallback chain in _cache_buckets for cache-read tokens: prompt_tokens_details.cached_tokens -> _cache_read_input_tokens (private attr) -> cached_tokens (model_extra)
  • Restore the fallback for cache-write tokens: prompt_tokens_details.cache_creation_tokens (guarded by model_fields_set) -> _cache_creation_input_tokens (private attr)
  • Added 3 regression tests covering Kimi-K2 cached_tokens, Anthropic _cache_read_input_tokens, and Anthropic _cache_creation_input_tokens

Issue Number

N/A — found during weekly regression hunt of PR #4968.

How to Test

Before (on unmodified main, commit 76e9e25):

$ git checkout 76e9e25 -- openhands-sdk/openhands/sdk/llm/utils/telemetry.py
$ uv run pytest tests/sdk/llm/test_llm_telemetry.py -k "kimi or anthropic" -v

# 2 tests FAIL:
# test_record_usage_cache_read_kimi_cached_tokens: FAILED
# test_record_usage_cache_read_anthropic_private_attr: FAILED

After (with this fix):

$ uv run pytest tests/sdk/llm/test_llm_telemetry.py -v

# All 45 tests PASS, including the 3 new regression tests:
# test_record_usage_cache_read_kimi_cached_tokens: PASSED
# test_record_usage_cache_read_anthropic_private_attr: PASSED
# test_record_usage_cache_write_anthropic_private_attr: PASSED

Full suite verification:

$ uv run pytest tests/sdk/llm/test_llm_telemetry.py -v
============================== 45 passed in 0.35s ==============================

Pre-commit hooks:

$ uv run pre-commit run --files openhands-sdk/openhands/sdk/llm/utils/telemetry.py tests/sdk/llm/test_llm_telemetry.py
# All hooks passed (ruff format, ruff lint, pycodestyle, pyright, forbidden dynamic attributes, import rules, tool registration)

Video/Screenshots

Not applicable — this is a backend telemetry fix with no UI changes. Test output above serves as reproduction evidence.

Design Doc

Not applicable — narrow fix restoring previously-working behavior.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes


🐳 Agent Server images for this PR — GHCR package, pull/run commands, and all pushed tags (click to expand)

GHCR package: https://github.com/OpenHands/agent-sdk/pkgs/container/agent-server

Variants & Base Images

Variant Architectures Base Image Docs / Tags
java amd64, arm64 eclipse-temurin:17-jdk Link
python-slim amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
python amd64, arm64 nikolaik/python-nodejs:python3.13-nodejs22-slim Link
golang amd64, arm64 golang:1.21-bookworm Link

Pull (multi-arch manifest)

# Each variant is a multi-arch manifest supporting both amd64 and arm64
docker pull ghcr.io/openhands/agent-server:1d220d0-python

Run

docker run -it --rm \
  -p 8000:8000 \
  --name agent-server-1d220d0-python \
  ghcr.io/openhands/agent-server:1d220d0-python

All tags pushed for this build

ghcr.io/openhands/agent-server:1d220d0-golang-amd64
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-golang-amd64
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-golang-amd64
ghcr.io/openhands/agent-server:1d220d0-golang_tag_1.21-bookworm-amd64
ghcr.io/openhands/agent-server:1d220d0-golang-arm64
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-golang-arm64
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-golang-arm64
ghcr.io/openhands/agent-server:1d220d0-golang_tag_1.21-bookworm-arm64
ghcr.io/openhands/agent-server:1d220d0-java-amd64
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-java-amd64
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-java-amd64
ghcr.io/openhands/agent-server:1d220d0-eclipse-temurin_tag_17-jdk-amd64
ghcr.io/openhands/agent-server:1d220d0-java-arm64
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-java-arm64
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-java-arm64
ghcr.io/openhands/agent-server:1d220d0-eclipse-temurin_tag_17-jdk-arm64
ghcr.io/openhands/agent-server:1d220d0-python-amd64
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-python-amd64
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-python-amd64
ghcr.io/openhands/agent-server:1d220d0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-amd64
ghcr.io/openhands/agent-server:1d220d0-python-arm64
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-python-arm64
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-python-arm64
ghcr.io/openhands/agent-server:1d220d0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-arm64
ghcr.io/openhands/agent-server:1d220d0-python-slim-amd64
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-python-slim-amd64
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-python-slim-amd64
ghcr.io/openhands/agent-server:1d220d0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-amd64
ghcr.io/openhands/agent-server:1d220d0-python-slim-arm64
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-python-slim-arm64
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-python-slim-arm64
ghcr.io/openhands/agent-server:1d220d0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim-arm64
ghcr.io/openhands/agent-server:1d220d0-golang
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-golang
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-golang
ghcr.io/openhands/agent-server:1d220d0-golang_tag_1.21-bookworm
ghcr.io/openhands/agent-server:1d220d0-java
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-java
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-java
ghcr.io/openhands/agent-server:1d220d0-eclipse-temurin_tag_17-jdk
ghcr.io/openhands/agent-server:1d220d0-python-slim
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-python-slim
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-python-slim
ghcr.io/openhands/agent-server:1d220d0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim-slim
ghcr.io/openhands/agent-server:1d220d0-python
ghcr.io/openhands/agent-server:1d220d0a1cfa3b1ca915b4075ee65b7f5e81f294-python
ghcr.io/openhands/agent-server:fix-telemetry-cache-token-regression-python
ghcr.io/openhands/agent-server:1d220d0-nikolaik_s_python-nodejs_tag_python3.13-nodejs22-slim

About Multi-Architecture Support

  • Each variant tag (e.g., 1d220d0-python) is a multi-arch manifest supporting both amd64 and arm64
  • Docker automatically pulls the correct architecture for your platform
  • Individual architecture tags (e.g., 1d220d0-python-amd64) are also available if needed

PR #4968 rewrote _cache_buckets to only check prompt_tokens_details,
dropping fallback paths for Kimi-K2 (cached_tokens in model_extra) and
Anthropic (_cache_read_input_tokens / _cache_creation_input_tokens in
private attrs). This silently zeroed cache-read and cache-write token
accounting for those providers, producing incorrect cost metrics and
telemetry spans.

Restore the fallback chain while keeping the cleaner type-safe access:
  1. prompt_tokens_details.cached_tokens / cache_creation_tokens
  2. _cache_read_input_tokens / _cache_creation_input_tokens (private)
  3. cached_tokens (model_extra, Kimi-K2 / DeepSeek)

Added regression tests for each provider shape.

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions

Copy link
Copy Markdown
Contributor

REST API breakage checks (OpenAPI) — ✅ PASSED

Result:PASSED

Action log

@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Coverage Report •
FileStmtsMissCoverMissing
openhands-sdk/openhands/sdk/llm/utils
   telemetry.py2301992%143, 168, 174–175, 185, 197–198, 293–295, 318–319, 330–331, 439, 441, 455, 461, 466
TOTAL423431178172% 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants